package de.programmerspain.rv3sf.api;

import gnu.io.CommPortIdentifier;
import gnu.io.SerialPort;

import java.io.IOException;
import java.util.Arrays;

import org.apache.commons.lang3.Validate;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * This class provides an easy to use API to the 'rainbowduino-v3-streaming-firmware'
 * (https://code.google.com/p/rainbowduino-v3-streaming-firmware/) which is able to send
 * 24bit RGB frames to a Rainbowduino V3 controller with ~35 frames per second.
 * 
 * @author Markus Lang (m@rkus-lang.de) | http://programmers-pain.de/ | https://code.google.com/p/rainbowduino-v3-streaming-firmware/
 */
public class RainbowduinoV3 {
	private static final Logger LOGGER = LoggerFactory.getLogger(RainbowduinoV3.class);
	
	private static final int NUMBER_OF_LEDS = 64;
	private static final byte[] FRAME_FRAGMENTS = {0x20, 0x21, 0x22, 0x23};
	private static final int PIXEL_PER_FRAME_FRAGMENT = NUMBER_OF_LEDS / FRAME_FRAGMENTS.length;

	private static final byte HEADER = 0x10;
	private static final int HEADER_LENGTH = 3;
	
	// serial data format
	// 1: HEADER
	// 2: HEADER
	// 3: FRAME_FRAGMENT_INDEX
	// 4-51: rgb values of the 16 LEDs fragments (16 * R * G * B = 48)
	private final byte[] serialData;
	
	// ack reply serial data format
	// 0: HEADER
	// 1: VALUE1
	// 2: VALUE2
	// 3: ERROR CODE
	private static final int ACK_REPLY_LENGTH = 4;
	
	private static final int MAX_ACK_WAIT_TIME = 50;
	private static final int BAUD_RATE = 115200;
	
	private SerialPort serialPort;
	private GammaTable gammaTable;
	
	private long errorCounter;
	private long numberOfSentFrames;
	
	/**
	 * constructs an <code>RainbowduinoV3</code> instance for the given
	 * <code>serialPortName</code> serial port. If a symlink was specified
	 * the real device name will be resolved first. The provided
	 * <code>gammaTable</code> gamma table instance will be used to
	 * translate the frame values of every <code>sendFrame</code> method
	 * call to the desired color values first. This can be used to limit
	 * the max. brightness of all LEDs or to decrease the max. color value
	 * of one or more color channels if needed.
	 * 
	 * @param serialPortName a string containing the serial port device name
	 * @param gammaTable an instance of the GammaTable class
	 */
	public RainbowduinoV3(String serialPortName, GammaTable gammaTable) {
		Validate.notNull(serialPortName);
		Validate.notEmpty(serialPortName);
		Validate.notNull(gammaTable);
		// assign class variables
		this.gammaTable = gammaTable;
		// initialize variables
		this.errorCounter = 0;
		this.numberOfSentFrames = 0;
		// create and initialize to be transfered byte[] array
		this.serialData = new byte[((PIXEL_PER_FRAME_FRAGMENT) * 3) + HEADER_LENGTH];
		// first two bytes are always the HEADER bytes
		this.serialData[0] = HEADER;
		this.serialData[1] = HEADER;

		// create commPortIdentifier instance of the RXTX library
		try {
			CommPortIdentifier commPortIdentifier = CommPortIdentifier.getPortIdentifier(serialPortName);
			this.serialPort = (SerialPort) commPortIdentifier.open(this.getClass().getName(), 2000); // wait for 2000ms
			this.serialPort.setSerialPortParams(
					BAUD_RATE,
					SerialPort.DATABITS_8,
					SerialPort.STOPBITS_1,
					SerialPort.PARITY_NONE
			);
		} catch (Exception e) {
			throw new RainbowduinoV3Exception("Unable to initialize serial port: '" + serialPortName + "': " + e.getMessage(), e);
		}
		
		// give the serial port some time to get ready
		try {
			Thread.sleep(500);
		} catch (InterruptedException e) {
			LOGGER.warn("Got interrupted while wating for serial port: '" + serialPortName + "' getting ready: " + e.getMessage(), e);
		}
		LOGGER.info("Created " + this.getClass().getSimpleName() + " instance using serial port: '" + serialPortName + "'");
	}
	
	/**
	 * returns the number of errors that have been occurred so far
	 * 
	 * @return the number of errors
	 */
	public long getErrorCounter() {
		return this.errorCounter;
	}
	
	/**
	 * returns the total number of frames this instance has send so far
	 * 
	 * @return the number of send frames
	 */
	public long getNumberOfSentFrames() {
		return this.numberOfSentFrames;
	}
	
	/**
	 * checks if the internal serial port in initialized
	 * 
	 * @return <code>true</code> if the internal serial port is initialized
	 */
	public boolean isInitialized() {
		return this.serialPort != null;
	}
	
	/**
	 * call this method to close the internal serial port
	 */
	public void close() {
		this.serialPort.close();
		LOGGER.info("Stopped " + this.getClass().getSimpleName() + " instance using serial port:' " + this.serialPort.getName() + "'");
		this.serialPort = null;
	}
	
	/**
	 * accepts an int[] array containing the 64 RGB values
	 * for each pixel that you want to send to the controller.
	 * 
	 * @param frame the frame you want to send to the controller
	 */
	public void sendFrame(int[] frame) {
		// check that the serial port is initialized
		if (this.serialPort == null) {
			throw new RainbowduinoV3Exception("Unable to send frame since the serialPort '" + this.serialPort.getName() + "' isn't initialized!");
		}
		for (int fragment = 0; fragment < FRAME_FRAGMENTS.length; fragment++) {
			// set the matching frame fragment index in the header
			this.serialData[2] = FRAME_FRAGMENTS[fragment];
			// write pixel values to byte array
			short crc = 0;
			for (int index = 0; index < PIXEL_PER_FRAME_FRAGMENT; index++) {
				int pixel = index + (PIXEL_PER_FRAME_FRAGMENT * fragment);
				// split pixel into RGB colors
				short red = (short) ((frame[pixel] >> 16) & 0xFF);
				short green = (short) ((frame[pixel] >> 8) & 0xFF);
				short blue = (short) (frame[pixel] & 0xFF);
				// translate color value via the internal gamma table
				red = this.gammaTable.getValue(ColorEnum.RED, red);
				green = this.gammaTable.getValue(ColorEnum.GREEN, green);
				blue = this.gammaTable.getValue(ColorEnum.BLUE, blue);
				// add color values to the crc value
				crc += red;
				crc += green;
				crc += blue;
				// set color values in the serialData array
				this.serialData[HEADER_LENGTH + (index * 3)] = (byte) red;
				this.serialData[HEADER_LENGTH + (index * 3) + 1] = (byte) green;
				this.serialData[HEADER_LENGTH + (index * 3) + 2] = (byte) blue;
			}
			// send data to the controller
			try {
				this.serialPort.getOutputStream().write(serialData);
			} catch (IOException e) {
				LOGGER.warn("Unable to send data to the controller: " + e.getMessage(), e);
			}
			// receive ack reply from the controller
			try {
				waitForACK(fragment, crc);
			} catch (IOException e) {
				LOGGER.warn("Unable to receive data from the controller: " + e.getMessage(), e);
			}
		}
		// increment frame counter
		this.numberOfSentFrames++;
	}
	
	// this method waits for a reply from the controller after a frame has been send
	private boolean waitForACK(int fragment, short crc) throws IOException {
		// wait until the serial port is able to deliver the reply message
		long waitTime = System.currentTimeMillis() + MAX_ACK_WAIT_TIME;
		while (waitTime > System.currentTimeMillis() && this.serialPort.getInputStream().available() < ACK_REPLY_LENGTH) {
			try {
				Thread.sleep(0, 250);
			} catch (InterruptedException e) {
				LOGGER.warn("Got interrupted while wating for ACK_REPLY message: " + e.getMessage(), e);
			}
		}
		// check if serial port is able to offer the reply message
		if (this.serialPort.getInputStream().available() < ACK_REPLY_LENGTH) {
			LOGGER.warn("No serial ACK reply received for frame fragment " + fragment + " after waiting for " + MAX_ACK_WAIT_TIME + "ms. Serial port has only " + serialPort.getInputStream().available() + " bytes available.");
			this.errorCounter++;
			return false;
		}
		// read data from serial port
		byte[] reply = new byte[this.serialPort.getInputStream().available()];
		this.serialPort.getInputStream().read(reply);
		// search for the HEADER byte
		for (int i = 0; i < reply.length; i++) {
			if (reply[i] == HEADER) {
				try {
					// parse the state code returned by the controller
					RainbowduinoV3StateEnum stateCode = RainbowduinoV3StateEnum.getErrorForCode(reply[i + 3]);
					if (stateCode == null) {
						LOGGER.warn("Unable resolve stateCode for byte: " + reply[i + 3]);
						this.errorCounter++;
						return false;
					}
					// get the value of the returned stateCode
					short receivedValue = (short) (((int) reply[i + 1] & 0xff) << 8 | (int) reply[i + 2] & 0xff);
					if (LOGGER.isTraceEnabled()) {
						LOGGER.trace("received state value: " + receivedValue);
					}
					// check the crc value of the controllers ACK message
					if (stateCode.equals(RainbowduinoV3StateEnum.STATE_ACK) && receivedValue != crc) {
						LOGGER.warn("CRC missmatch! send CRC was: '" + crc + "', controller returned CRC: '" + receivedValue + "'");
						this.errorCounter++;
						return false;
					}
					// check if controller has returned with an error stateCode
					if (stateCode.isError()) {
						LOGGER.warn("Controller has returned error code: '" + stateCode + "', error value: '" + receivedValue + "'");
						this.errorCounter++;
						return false;
					}
					// we've found a HEADER byte, there was no error code and the
					// CRC calculated by the controller is identical to the one we've send
					return true;
				} catch (ArrayIndexOutOfBoundsException e) {
					LOGGER.error("Received incomplete ack reply message: " + Arrays.toString(reply));
					this.errorCounter++;
					return false;
				}
			}
		}
		// if we reach this point than we've received an invalid ACK reply
		return false;
	}
}
